Designing Reusable and Maintainable Object-Oriented Applications in Python
Course: Python Training
Topic: Designing Reusable and Maintainable Object-Oriented Applications
Course Link: Python Training Course
Demo Link: Register for a Course Demo
Website: JustAcademy
1. Introduction to Reusable and Maintainable Applications
Object-Oriented Programming (OOP) is a programming approach that organizes software around classes and objects. It helps developers combine data and behavior into logical units, making applications easier to understand, extend, test, and maintain.
Designing reusable and maintainable applications means creating software components that can be used in multiple situations, modified without unnecessary side effects, and understood by other developers. A well-designed application should be easy to improve when business requirements change.
For example, an e-commerce application may contain separate classes for customers, products, shopping carts, orders, payments, and notifications. Each class has a clear responsibility and collaborates with other classes to complete the application's tasks.
Learning Objectives
- Understand the principles of reusable and maintainable software design.
- Learn how to design classes with clear responsibilities.
- Apply encapsulation, abstraction, inheritance, and polymorphism appropriately.
- Understand composition, aggregation, and dependency injection.
- Reduce duplicate code by using reusable methods and components.
- Learn SOLID principles and their application in Python.
- Improve code readability, testing, documentation, and error handling.
- Build a small maintainable application using object-oriented design.
2. What Is Code Reusability?
Code reusability is the practice of writing code that can be used in multiple parts of an application or across different projects without repeatedly implementing the same functionality.
Reusable code reduces duplication, improves consistency, and makes updates easier. When a reusable component needs to change, developers can update it in one place instead of modifying several copies of the same logic.
Example Without Code Reusability
customer1_name = "Amit"
customer1_price = 1500
customer1_tax = customer1_price * 0.18
customer1_total = customer1_price + customer1_tax
customer2_name = "Priya"
customer2_price = 2500
customer2_tax = customer2_price * 0.18
customer2_total = customer2_price + customer2_tax
print(customer1_name, customer1_total)
print(customer2_name, customer2_total)
This example repeats the tax calculation for every customer. If the tax calculation changes, each repeated section may need to be updated.
Improved Example Using a Reusable Function
def calculate_total(price, tax_rate=0.18):
tax = price * tax_rate
return price + tax
print(calculate_total(1500))
print(calculate_total(2500))
Output
1770.0
2950.0
The calculation is defined once and reused for multiple prices. The same principle can be applied to class methods, services, modules, and larger application components.
Benefits of Code Reusability
- Reduces repeated code.
- Improves consistency across the application.
- Makes changes easier to implement.
- Reduces the chance of fixing a bug in one place but missing another copy.
- Makes testing and debugging more efficient.
- Encourages modular software architecture.
3. What Is Maintainability?
Maintainability is the ease with which developers can understand, correct, test, modify, and extend software after it has been created.
A maintainable application is not necessarily the application with the fewest lines of code. It is an application whose structure makes its behavior understandable and whose components can be changed without creating unnecessary problems elsewhere.
Characteristics of Maintainable Software
- Readability: Code is easy to understand.
- Modularity: The application is divided into logical components.
- Low coupling: Components depend as little as practical on one another's internal details.
- High cohesion: Each class focuses on closely related responsibilities.
- Testability: Components can be tested independently.
- Extensibility: New features can be added without extensive rewrites.
- Documentation: Important decisions and public interfaces are explained.
- Consistency: Naming, formatting, and project structure follow recognizable conventions.
Example of Difficult-to-Maintain Code
class Application:
def run(self):
# Read customer data
# Validate customer data
# Calculate order amount
# Process payment
# Save order
# Send email notification
print("Everything completed")
This class combines several unrelated responsibilities. If the payment provider changes or the notification method needs updating, the same class may require modifications in several places.
Improved Design
class CustomerValidator:
def validate(self, customer):
return bool(customer.get("name"))
class PriceCalculator:
def calculate(self, price, quantity):
return price * quantity
class EmailNotifier:
def send(self, message):
print(f"Email notification: {message}")
validator = CustomerValidator()
calculator = PriceCalculator()
notifier = EmailNotifier()
customer = {"name": "Amit"}
if validator.validate(customer):
total = calculator.calculate(500, 2)
notifier.send(f"Order total is ₹{total}")
Output
Email notification: Order total is ₹1000
Each class now has a focused responsibility. This makes the individual operations easier to understand, test, replace, and extend.
4. Core Principles of Object-Oriented Programming
Four foundational OOP principles help developers design structured and reusable applications.
4.1 Encapsulation
Encapsulation combines data and related methods in a class and provides a controlled way to interact with the object's state.
class BankAccount:
def __init__(self, balance=0):
if balance < 0:
raise ValueError("Balance cannot be negative")
self._balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def get_balance(self):
return self._balance
account = BankAccount(1000)
account.deposit(500)
print(account.get_balance())
Output
1500
The balance is updated through a method that validates the operation. The underscore in _balance signals that it is intended for internal use; Python does not enforce strict private access for a single-underscore attribute.
4.2 Abstraction
Abstraction exposes essential operations while hiding implementation details. Abstract base classes can define a common contract for related implementations.
from abc import ABC, abstractmethod
class ReportExporter(ABC):
@abstractmethod
def export(self, data):
pass
class TextExporter(ReportExporter):
def export(self, data):
return "\n".join(map(str, data))
exporter = TextExporter()
print(exporter.export(["Sales", 100, 200]))
Output
Sales
100
200
4.3 Inheritance
Inheritance allows a class to reuse or specialize behavior from another class. It should be used when the subclass represents a meaningful specialized form of the parent class.
class Employee:
def __init__(self, name):
self.name = name
def display(self):
return self.name
class Developer(Employee):
def work(self):
return f"{self.name} writes code"
developer = Developer("Amit")
print(developer.display())
print(developer.work())
Output
Amit
Amit writes code
4.4 Polymorphism
Polymorphism allows different objects to respond to the same operation according to their own implementations.
class PDFReport:
def export(self):
return "Exporting PDF report"
class CSVReport:
def export(self):
return "Exporting CSV report"
def generate_report(report):
print(report.export())
generate_report(PDFReport())
generate_report(CSVReport())
Output
Exporting PDF report
Exporting CSV report
The function works with either object because both provide the required export() method. This is an example of polymorphism through duck typing.
5. Designing Classes with Clear Responsibilities
A class should represent a meaningful concept and contain data and behavior that belong together. A class that handles too many unrelated tasks becomes difficult to understand, test, and change.
Example of a Poorly Designed Class
class EmployeeManager:
def add_employee(self, employee):
print("Employee added")
def calculate_salary(self, salary, bonus):
return salary + bonus
def generate_report(self):
print("Report generated")
def send_email(self, email):
print(f"Email sent to {email}")
This class manages employees, calculates salaries, generates reports, and sends emails. These responsibilities may change for different reasons, which makes the class harder to maintain.
Improved Class Design
class EmployeeRepository:
def add_employee(self, employee):
print(f"Employee saved: {employee}")
class SalaryCalculator:
def calculate(self, salary, bonus):
return salary + bonus
class ReportGenerator:
def generate(self, employees):
return f"Report contains {len(employees)} employees"
class EmailService:
def send(self, email):
print(f"Email sent to {email}")
Advantages
- Each class has a clear purpose.
- Individual classes can be tested separately.
- Changing the email implementation does not require rewriting salary calculations.
- New implementations can be introduced without changing unrelated components.
- Other developers can locate the relevant functionality more easily.
6. Understanding High Cohesion and Low Coupling
High cohesion and low coupling are important design goals for reusable and maintainable applications.
High Cohesion
Cohesion describes how closely the responsibilities within a module or class relate to one another. A highly cohesive class focuses on one clear area of functionality.
For example, a TaxCalculator should calculate taxes rather than also managing user accounts and sending emails.
Low Coupling
Coupling describes the degree of dependency between components. Low coupling means one component does not rely unnecessarily on the internal details of another component.
Example of Tight Coupling
class EmailService:
def send(self, message):
print(f"Email sent: {message}")
class OrderService:
def __init__(self):
self.email_service = EmailService()
def complete_order(self):
print("Order completed")
self.email_service.send("Your order is complete")
The OrderService directly creates an EmailService. Replacing email notifications with SMS notifications may require changes to the order service.
Example with Dependency Injection
class EmailService:
def send(self, message):
print(f"Email sent: {message}")
class SMSService:
def send(self, message):
print(f"SMS sent: {message}")
class OrderService:
def __init__(self, notifier):
self.notifier = notifier
def complete_order(self):
print("Order completed")
self.notifier.send("Your order is complete")
order_service = OrderService(EmailService())
order_service.complete_order()
sms_order_service = OrderService(SMSService())
sms_order_service.complete_order()
Output
Order completed
Email sent: Your order is complete
Order completed
SMS sent: Your order is complete
The notification dependency is supplied from outside the class. This is called dependency injection. It makes the order service more flexible because different notification implementations can be supplied without changing its core logic.
7. Applying SOLID Principles
SOLID is a group of five object-oriented design principles that help developers build flexible, understandable, and maintainable software.
| Principle |
Meaning |
Purpose |
| Single Responsibility Principle (SRP) |
A class should have one primary responsibility. |
Limits unrelated reasons for changing a class. |
| Open/Closed Principle (OCP) |
Software entities should be open for extension but closed for unnecessary modification. |
Supports adding new behavior through suitable abstractions and implementations. |
| Liskov Substitution Principle (LSP) |
Subtypes should remain usable wherever their base type is expected. |
Prevents subclasses from breaking established behavior. |
| Interface Segregation Principle (ISP) |
Clients should not depend on methods they do not need. |
Encourages smaller, focused interfaces. |
| Dependency Inversion Principle (DIP) |
High-level policy should depend on abstractions rather than concrete implementation details. |
Reduces tight coupling between application logic and external services. |
7.1 Single Responsibility Principle
A class should have one primary responsibility and a clear reason to change.
class Invoice:
def __init__(self, amount):
self.amount = amount
class InvoicePrinter:
def print_invoice(self, invoice):
print(f"Invoice amount: ₹{invoice.amount}")
invoice = Invoice(2500)
printer = InvoicePrinter()
printer.print_invoice(invoice)
7.2 Open/Closed Principle
New functionality should be introduced through extension points when possible instead of repeatedly modifying stable code.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, amount):
pass
class NoDiscount(Discount):
def apply(self, amount):
return amount
class PercentageDiscount(Discount):
def __init__(self, percentage):
self.percentage = percentage
def apply(self, amount):
return amount * (1 - self.percentage / 100)
def calculate_price(amount, discount):
return discount.apply(amount)
print(calculate_price(1000, NoDiscount()))
print(calculate_price(1000, PercentageDiscount(10)))
Output
1000
900.0
A new discount strategy can be added by implementing the Discount abstraction without rewriting the calculate_price() function.
7.3 Liskov Substitution Principle
A subclass should preserve the behavior promised by its base type. A class should not inherit from another type if its behavior fundamentally violates the expectations of that type.
class PaymentMethod:
def pay(self, amount):
raise NotImplementedError
class CardPayment(PaymentMethod):
def pay(self, amount):
return f"Card payment processed: ₹{amount}"
class UpiPayment(PaymentMethod):
def pay(self, amount):
return f"UPI payment processed: ₹{amount}"
def checkout(payment_method, amount):
return payment_method.pay(amount)
print(checkout(CardPayment(), 500))
print(checkout(UpiPayment(), 800))
Both implementations support the expected payment operation, so the checkout function can use either one. A production implementation should also define consistent failure and transaction behavior.
7.4 Interface Segregation Principle
Interfaces should remain focused so that a class is not forced to implement unrelated operations.
from abc import ABC, abstractmethod
class Printable(ABC):
@abstractmethod
def print_document(self):
pass
class Scannable(ABC):
@abstractmethod
def scan_document(self):
pass
class BasicPrinter(Printable):
def print_document(self):
return "Document printed"
class MultiFunctionPrinter(Printable, Scannable):
def print_document(self):
return "Document printed"
def scan_document(self):
return "Document scanned"
print(BasicPrinter().print_document())
print(MultiFunctionPrinter().scan_document())
A basic printer only implements printing, while a multifunction printer implements both printing and scanning. The design avoids forcing every printer to support scanning.
7.5 Dependency Inversion Principle
High-level application logic should rely on an abstraction rather than directly depending on a particular service implementation.
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, message):
pass
class EmailSender(MessageSender):
def send(self, message):
print(f"Email: {message}")
class AlertManager:
def __init__(self, sender):
self.sender = sender
def notify(self, message):
self.sender.send(message)
manager = AlertManager(EmailSender())
manager.notify("System backup completed")
The alert manager receives a message sender instead of constructing a specific sender internally. Another compatible sender can be introduced with minimal changes to the alert manager.
8. Composition Over Inheritance
Composition allows one object to use other objects to perform its work. It is often preferable to deep inheritance hierarchies when an application needs flexible combinations of behavior.
Inheritance Example
class Vehicle:
def start(self):
return "Vehicle started"
class Car(Vehicle):
pass
car = Car()
print(car.start())
Composition Example
class Engine:
def start(self):
return "Engine started"
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
return self.engine.start()
car = Car(Engine())
print(car.start())
Output
Engine started
Composition makes it possible to supply different engine implementations without changing the car's basic interface. Inheritance remains appropriate when a genuine is-a relationship exists, but composition is often more flexible for combining independent behaviors.
When to Use Composition
- When a class needs services supplied by other objects.
- When dependencies may need to be replaced.
- When behavior should be combined without creating a large class hierarchy.
- When components need to be tested independently.
9. Designing Reusable Methods and Classes
Reusable methods perform a clearly defined operation and accept parameters instead of relying on hard-coded values. Reusable classes encapsulate related state and behavior so they can be used in different parts of an application.
Example: Reusable Product Class
class Product:
def __init__(self, name, price):
if price < 0:
raise ValueError("Price cannot be negative")
self.name = name
self.price = price
def calculate_total(self, quantity):
if quantity <= 0:
raise ValueError("Quantity must be positive")
return self.price * quantity
keyboard = Product("Keyboard", 1200)
mouse = Product("Mouse", 600)
print(keyboard.calculate_total(2))
print(mouse.calculate_total(3))
Output
2400
1800
The same class works with different product names, prices, and quantities. Validation is centralized in the class, reducing the need to repeat these checks throughout the application.
Guidelines for Reusable Methods
- Use meaningful method names that describe the action.
- Accept variable information through parameters.
- Return results when callers need to use them.
- Avoid unnecessary global variables.
- Validate input where the method's responsibility requires it.
- Keep methods focused on one operation.
- Document important assumptions and side effects.
10. Using Modules and Packages
Modules and packages organize reusable code into separate files and directories. This makes large applications easier to navigate and maintain.
What Is a Module?
A module is generally a Python file containing classes, functions, constants, or related logic that can be imported by another file.
Example Project Structure
shop_app/
main.py
models.py
services.py
validators.py
notifications.py
models.py
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
services.py
class PriceCalculator:
def calculate_total(self, product, quantity):
return product.price * quantity
main.py
from models import Product
from services import PriceCalculator
product = Product("Keyboard", 1200)
calculator = PriceCalculator()
total = calculator.calculate_total(product, 2)
print(f"Total: ₹{total}")
Output
Total: ₹2400
The product model and price calculation service are kept in separate modules. This separation allows each component to evolve independently while remaining available to other parts of the application.
Benefits of Modules and Packages
- Organize code by responsibility.
- Make components easier to locate and test.
- Encourage reuse across multiple files.
- Reduce the complexity of individual source files.
- Make larger applications easier to collaborate on.
11. Naming Conventions and Readability
Readable code is easier to maintain because developers spend less time understanding what a class or method does. Python code commonly follows conventions described in PEP 8.
| Element |
Recommended Style |
Example |
| Class names |
CapWords or PascalCase |
OrderService |
| Function names |
Lowercase with underscores |
calculate_total() |
| Method names |
Lowercase with underscores |
validate_order() |
| Constants |
Uppercase with underscores |
DEFAULT_TAX_RATE |
| Internal attributes |
Leading underscore by convention |
_balance |
| Module names |
Short lowercase names |
order_service.py |
Example of Poor Naming
class X:
def f(self, a, b):
return a * b
Improved Naming
class PriceCalculator:
def calculate_total(self, price, quantity):
return price * quantity
The second example clearly communicates the class's purpose and the method's operation. Good names reduce the need for excessive comments.
12. Documentation and Docstrings
Documentation explains how a class, method, or module should be used. Python docstrings are string literals placed at the beginning of a module, class, or function to describe its purpose.
Example
class TemperatureConverter:
"""Convert temperatures between Celsius and Fahrenheit."""
def celsius_to_fahrenheit(self, celsius):
"""Convert Celsius to Fahrenheit.
Args:
celsius: Temperature in degrees Celsius.
Returns:
Temperature in degrees Fahrenheit.
"""
return (celsius * 9 / 5) + 32
converter = TemperatureConverter()
print(converter.celsius_to_fahrenheit(25))
print(TemperatureConverter.__doc__)
Output
77.0
Convert temperatures between Celsius and Fahrenheit.
Documentation Best Practices
- Document the purpose of public classes and methods.
- Explain parameters and return values when they are not obvious.
- Describe important exceptions and side effects.
- Keep documentation consistent with actual behavior.
- Update documentation when the public interface changes.
- Avoid comments that merely repeat what the code already makes obvious.
13. Error Handling and Exception Design
Reliable applications handle expected errors clearly instead of allowing failures to produce confusing behavior. Exceptions help communicate when an operation cannot be completed.
Example of Input Validation
class BankAccount:
def __init__(self, balance=0):
if balance < 0:
raise ValueError("Initial balance cannot be negative")
self.balance = balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self.balance:
raise ValueError("Insufficient balance")
self.balance -= amount
return self.balance
account = BankAccount(2000)
try:
print(account.withdraw(500))
print(account.withdraw(3000))
except ValueError as error:
print(f"Transaction failed: {error}")
Output
1500
Transaction failed: Insufficient balance
Best Practices for Error Handling
- Raise meaningful exceptions when an operation violates a requirement.
- Use built-in exception types such as
ValueError and TypeError when appropriate.
- Create custom exception classes for meaningful application-specific errors.
- Catch exceptions at a level where the program can handle them appropriately.
- Avoid catching every exception without a clear reason.
- Do not silently ignore errors that affect correctness.
- Keep error messages helpful without exposing passwords, tokens, or other sensitive information.
14. Unit Testing Object-Oriented Applications
Unit testing verifies that individual functions, methods, and classes behave as expected. Testing is especially important in reusable applications because a change to a shared component can affect multiple parts of the system.
Example Class to Test
class Calculator:
def add(self, a, b):
return a + b
def multiply(self, a, b):
return a * b
Unit Test Using unittest
import unittest
class Calculator:
def add(self, a, b):
return a + b
def multiply(self, a, b):
return a * b
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calculator = Calculator()
def test_add(self):
self.assertEqual(self.calculator.add(2, 3), 5)
def test_multiply(self):
self.assertEqual(self.calculator.multiply(4, 3), 12)
if __name__ == "__main__":
unittest.main()
Explanation
unittest.TestCase provides the testing framework.
setUp() prepares a calculator instance for each test.
assertEqual() checks whether the actual and expected results match.
- Each test focuses on one behavior.
Save the test in a Python file and run it with the Python interpreter. Automated tests help identify regressions when reusable components are changed.
Testing Best Practices
- Test normal inputs and boundary cases.
- Test invalid inputs and expected exceptions.
- Test classes independently where practical.
- Use mocks or test doubles for external services when appropriate.
- Keep tests readable and deterministic.
- Run tests after significant changes and before releases.
15. Refactoring for Better Maintainability
Refactoring means improving the internal structure of existing code without intentionally changing its externally observable behavior.
Developers refactor code when they find duplication, overly large classes, unclear names, long methods, or unnecessary dependencies.
Before Refactoring
def calculate_book_total(price, quantity):
return price * quantity
def calculate_laptop_total(price, quantity):
return price * quantity
def calculate_phone_total(price, quantity):
return price * quantity
All three functions perform the same calculation. Maintaining separate copies adds unnecessary duplication.
After Refactoring
def calculate_total(price, quantity):
return price * quantity
print(calculate_total(500, 3))
print(calculate_total(40000, 1))
print(calculate_total(15000, 2))
Output
1500
40000
30000
Common Refactoring Techniques
- Extract repeated logic into a reusable function or method.
- Split large classes into smaller, focused classes.
- Rename variables and methods to improve clarity.
- Replace long conditional logic with appropriate strategies or polymorphism.
- Introduce abstractions when they solve a genuine design problem.
- Remove dead code and unnecessary dependencies.
- Run automated tests to verify that behavior remains correct.
16. Avoiding Common Object-Oriented Design Problems
| Problem |
Description |
Recommended Improvement |
| God object |
One class handles too many unrelated responsibilities. |
Split responsibilities into cohesive classes. |
| Code duplication |
The same business logic appears in multiple places. |
Extract reusable functions, methods, or components. |
| Tight coupling |
A class directly depends on many concrete implementations. |
Use focused interfaces and dependency injection where appropriate. |
| Deep inheritance hierarchy |
Behavior becomes difficult to trace across many parent classes. |
Prefer simpler hierarchies or composition where appropriate. |
| Large methods |
A method performs several unrelated operations. |
Break it into smaller methods with clear purposes. |
| Unclear naming |
Names do not communicate responsibilities. |
Use descriptive, consistent naming conventions. |
| Excessive abstraction |
Unnecessary classes and interfaces increase complexity. |
Introduce abstractions only when they provide practical value. |
| Uncontrolled shared state |
Many components modify the same mutable data. |
Clarify ownership and expose controlled operations. |
| Missing tests |
Changes can break existing behavior without detection. |
Add automated unit and integration tests. |
17. Designing an Application Architecture
Application architecture describes how major parts of a system are organized and interact. A simple layered architecture can separate user interaction, business logic, and data storage.
Common Application Layers
- Presentation layer: Handles user input and displays results.
- Service layer: Coordinates business operations and application workflows.
- Domain or model layer: Represents business entities and their rules.
- Repository or data-access layer: Handles data persistence and retrieval.
- Infrastructure layer: Provides external integrations such as email, payment gateways, or databases.
Example Architecture
Presentation
|
v
OrderService
|
+------> Product / Order Models
|
+------> OrderRepository
|
+------> NotificationService
The service coordinates the operation without needing to implement every detail of data storage or notification delivery itself.
Example Implementation
class Order:
def __init__(self, order_id, amount):
if amount < 0:
raise ValueError("Amount cannot be negative")
self.order_id = order_id
self.amount = amount
class OrderRepository:
def save(self, order):
print(f"Order {order.order_id} saved")
class NotificationService:
def send(self, message):
print(f"Notification: {message}")
class OrderService:
def __init__(self, repository, notifier):
self.repository = repository
self.notifier = notifier
def place_order(self, order):
self.repository.save(order)
self.notifier.send(
f"Order {order.order_id} placed successfully"
)
repository = OrderRepository()
notifier = NotificationService()
service = OrderService(repository, notifier)
order = Order("ORD101", 2500)
service.place_order(order)
Output
Order ORD101 saved
Notification: Order ORD101 placed successfully
This design separates the order model, persistence operation, notification operation, and application workflow. A real application could replace the repository with a database implementation and the notifier with an email or messaging provider.
18. Version Control and Team Collaboration
Reusable and maintainable applications are easier to develop when teams follow consistent collaboration practices. Version control systems such as Git help developers track changes, work on separate features, and review modifications before merging them.
Recommended Practices
- Use descriptive commit messages.
- Keep changes focused on one feature or fix when possible.
- Use branches for independent development tasks.
- Review code before merging important changes.
- Run automated tests during the development workflow.
- Document significant architectural decisions.
- Avoid committing passwords, API keys, or other secrets.
- Use dependency files to make project environments easier to reproduce.
Example Git Workflow
git status
git checkout -b feature/order-service
git add .
git commit -m "Add reusable order service"
git push -u origin feature/order-service
These commands illustrate a common branch-based workflow. The exact process depends on the team's repository policies and collaboration platform.
19. Performance and Maintainability
Maintainable code should also perform adequately for its intended workload. However, optimizing code prematurely can make an application more complicated without providing meaningful benefits.
Recommended Practices
- Choose suitable data structures and algorithms.
- Avoid repeating expensive calculations unnecessarily.
- Use generators when processing large streams of data where appropriate.
- Measure performance before making complex optimizations.
- Cache results only when the benefit and invalidation rules are understood.
- Keep performance-sensitive code covered by tests.
- Use profiling tools to identify actual bottlenecks.
- Balance optimization with readability and simplicity.
Example: Avoiding Repeated Calculation
class Report:
def __init__(self, values):
self.values = values
def calculate_total(self):
return sum(self.values)
def calculate_average(self):
if not self.values:
return 0
return self.calculate_total() / len(self.values)
report = Report([10, 20, 30, 40])
print(report.calculate_total())
print(report.calculate_average())
Output
100
25.0
The total calculation is reused by the average calculation, avoiding duplicate implementation of the summation logic. Whether caching is necessary depends on the size of the data and how frequently the values change.
20. Practical Exercises
Exercise 1: Student Management System
Create a Student class with student details and a StudentService class to validate and register students. Keep data representation separate from registration logic.
Exercise 2: Employee Payroll System
Create separate classes for employees, salary calculations, and payroll reporting. Implement reusable salary calculations and validate salary inputs.
Exercise 3: Notification System
Create a notification abstraction with email and SMS implementations. Inject the chosen notification service into a separate application service.
Exercise 4: Product Inventory System
Create a product model, inventory service, and inventory repository. Add methods to register products, update quantities, and retrieve stock information.
Exercise 5: Report Generator
Implement CSV and text exporters that follow a common interface. Make a report service that can use either exporter without rewriting its main workflow.
Exercise 6: Refactoring Practice
Take a class that performs data validation, business calculations, database operations, and notification delivery. Refactor it into smaller classes with clear responsibilities and test each component.
21. Mini Project: Maintainable E-Commerce Order Management System
Project Goal: Build a small object-oriented order management system using reusable classes, dependency injection, validation, and separation of responsibilities.
Project Requirements
- Create a
Product class to represent products and their prices.
- Create an
OrderItem class to represent a product and its quantity.
- Create an
Order class to calculate the total amount.
- Create a repository class to save orders.
- Create a notification service to inform the customer.
- Create an order service to coordinate the workflow.
- Validate product prices and order quantities.
- Keep the design flexible enough to replace the repository or notifier.
Sample Implementation
class Product:
def __init__(self, name, price):
if price < 0:
raise ValueError("Price cannot be negative")
self.name = name
self.price = price
class OrderItem:
def __init__(self, product, quantity):
if quantity <= 0:
raise ValueError("Quantity must be positive")
self.product = product
self.quantity = quantity
def subtotal(self):
return self.product.price * self.quantity
class Order:
def __init__(self, order_id, items):
if not items:
raise ValueError("Order must contain at least one item")
self.order_id = order_id
self.items = list(items)
def total(self):
return sum(item.subtotal() for item in self.items)
class OrderRepository:
def save(self, order):
print(f"Saved order: {order.order_id}")
class NotificationService:
def send(self, message):
print(f"Notification: {message}")
class OrderService:
def __init__(self, repository, notifier):
self.repository = repository
self.notifier = notifier
def place_order(self, order):
total = order.total()
self.repository.save(order)
self.notifier.send(
f"Order {order.order_id} placed. Total: ₹{total}"
)
return total
product1 = Product("Keyboard", 1200)
product2 = Product("Mouse", 600)
items = [
OrderItem(product1, 2),
OrderItem(product2, 1)
]
order = Order("ORD101", items)
service = OrderService(
OrderRepository(),
NotificationService()
)
print("Order total:", service.place_order(order))
Output
Saved order: ORD101
Notification: Order ORD101 placed. Total: ₹3000
Order total: 3000
Project Design Explanation
- Product: Stores product information and validates the price.
- OrderItem: Represents a product quantity and calculates the subtotal.
- Order: Groups order items and calculates the total.
- OrderRepository: Handles the saving responsibility.
- NotificationService: Handles notification delivery.
- OrderService: Coordinates the order workflow using injected dependencies.
The repository and notification implementations are demonstrations. A production system would need persistent database storage, reliable notification delivery, transaction management, and additional validation.
Possible Enhancements
- Add customer details and shipping addresses.
- Introduce a payment service abstraction.
- Implement database persistence.
- Add discounts and tax calculations.
- Create unit tests for totals, validation, and order placement.
- Use a logging module to record failures and important events.
- Add order statuses such as pending, confirmed, and cancelled.
22. Interview Questions and Answers
Q1. What is code reusability?
Code reusability is the practice of designing functions, classes, and modules so they can be used in multiple situations without duplicating their implementation.
Q2. What is maintainability?
Maintainability describes how easily software can be understood, corrected, tested, modified, and extended.
Q3. What are the four main principles of OOP?
The four commonly recognized principles are encapsulation, abstraction, inheritance, and polymorphism.
Q4. What is the Single Responsibility Principle?
It states that a class should have one primary responsibility and a focused reason to change.
Q5. What is the Open/Closed Principle?
It encourages designing software so new behavior can be added through extension without repeatedly modifying stable, existing code.
Q6. What is dependency injection?
Dependency injection means supplying an object with the services or dependencies it needs instead of requiring it to construct every dependency itself.
Q7. What is the difference between high cohesion and low coupling?
High cohesion means the responsibilities inside a component are closely related. Low coupling means components have fewer unnecessary dependencies on one another.
Q8. Why is composition often preferred over inheritance?
Composition can make it easier to combine and replace behaviors without creating a rigid class hierarchy. Inheritance is still useful when a genuine is-a relationship exists.
Q9. What is refactoring?
Refactoring improves a program's internal structure without intentionally changing its externally observable behavior.
Q10. How do modules improve maintainability?
Modules group related functionality into separate files, making code easier to locate, reuse, test, and maintain.
Q11. Why are unit tests important?
Unit tests verify the behavior of individual components and help detect regressions when code changes.
Q12. What is the Liskov Substitution Principle?
It requires subtypes to preserve the behavioral expectations of their base types so that they can be substituted without breaking the program.
Q13. What is the Interface Segregation Principle?
It recommends small, focused interfaces so that classes do not depend on operations they do not need.
Q14. How can duplicate code be reduced?
Repeated logic can be extracted into reusable functions, methods, classes, or suitable shared components.
Q15. What makes a class difficult to maintain?
A class becomes difficult to maintain when it has too many responsibilities, unclear names, duplicated logic, excessive dependencies, or insufficient tests.
Q16. Why is documentation important?
Documentation explains how components should be used, clarifies important assumptions, and helps other developers understand the application.
Q17. How do you make a class easier to test?
Give it a clear responsibility, minimize unnecessary dependencies, inject external services where appropriate, and make its behavior predictable.
Q18. What is the Dependency Inversion Principle?
It encourages high-level business logic to depend on abstractions rather than concrete implementation details.
Q19. What is the difference between reusable and maintainable code?
Reusable code can be applied in multiple contexts. Maintainable code is easy to understand, modify, debug, and extend. Good software often aims to achieve both.
Q20. How do you decide whether to introduce an abstraction?
Introduce an abstraction when it clarifies responsibilities, supports meaningful variation, reduces coupling, or improves testing. Avoid abstractions that add complexity without practical benefits.
23. Quick Revision Notes
- Reusable code avoids unnecessary duplication.
- Maintainable code is readable, testable, modular, and easy to change.
- Encapsulation organizes data and behavior and provides controlled access to object state.
- Abstraction exposes essential operations while hiding implementation details.
- Inheritance supports specialization and reuse through a parent-child relationship.
- Polymorphism allows different objects to respond to a common operation.
- High cohesion and low coupling improve software design.
- SOLID principles help structure flexible object-oriented applications.
- Composition and dependency injection help reduce unnecessary coupling.
- Modules and packages organize reusable code.
- Meaningful naming and docstrings improve readability.
- Exception handling makes failures easier to understand and manage.
- Unit tests protect existing behavior during refactoring.
- Version control and code reviews support reliable team development.
- Abstractions should solve real problems rather than add unnecessary complexity.
Object-Oriented Design Checklist
- Does every class have a clear responsibility?
- Is duplicated business logic minimized?
- Are class and method names descriptive?
- Are dependencies easy to replace when necessary?
- Are inputs validated where appropriate?
- Are errors handled clearly?
- Are public methods and important assumptions documented?
- Are critical behaviors covered by automated tests?
- Can new features be added without unnecessary changes to unrelated components?
- Is the design simpler than the problem requires, rather than more complicated?
24. Conclusion
Designing reusable and maintainable object-oriented applications requires more than creating classes and objects. Developers must carefully organize responsibilities, reduce duplication, manage dependencies, and create clear interfaces between components.
Principles such as encapsulation, abstraction, polymorphism, composition, high cohesion, low coupling, and SOLID provide practical guidance for building software that can grow with changing requirements. Meaningful naming, documentation, exception handling, unit testing, and refactoring further improve the quality of an application.
The best design is not necessarily the most complex one. It is the design that solves the problem clearly, supports necessary changes, and remains understandable to the people who maintain it.
Continue learning object-oriented programming and Python development through the Python Training Course at JustAcademy. Visit the JustAcademy website or register for a course demo.